cover photo
updated on Jun 4, 2025

How to Add Type-Safety to Your Translations

Internationalization (i18n) is a crucial aspect of building any multilingual application. While modern frameworks like Next.js offer out-of-the-box solutions—such as next-intl—that provide type-safe translation handling, many projects still operate without these benefits.
If you're working with Next.js, check out this guide: Type-Safe i18n in Next.js: A Complete Guide.

In this article, we’ll focus on those projects that can’t easily adopt a plug-and-play solution and need to implement internationalization logic manually.

Directory Structure

Let’s assume you have a list of features in your app, each with its own set of translations. A common approach is to organize all translation files under a single directory.

Two widely used directory structures are:

  • translations/[feature]/[locale].yml (or .json)
  • translations/[locale]/[feature].yml (or .json)

For this article, we’ll go with the following assumptions (though the concepts apply broadly):

  • Translation files are written in .yml.
  • Each .yml file starts with a root key that matches the feature name.

Here’s an example of what the translation file for the user feature might look like:

user:
	addUser: Add user
	editUser: Edit user
	messages:
		userAdded: user {fullName} added
		userEdited: user {fullName} edited

The Solution

To introduce type-safety into your translation system, you’ll need to follow a few key steps:

  • Generate Type Declarations: Write a script that reads the default locale’s translation files and automatically generates TypeScript type definitions based on their structure.
  • Add a Build Script: Add this script to your package.json as a command—let’s call it intl:types.
  • Ensure Pre-Build Execution: Make sure intl:types runs before your main build process to keep the generated types up-to-date.
  • Type the Translation Function: Either override the type definition of your existing translation function (commonly named t) or create a new, typed version of it.

Generate Type Declarations

The following script, written in JavaScript, generates TypeScript declaration files from your translation files.

It assumes the translation folder follows the structure: translations/[feature]/en-us.yml, where en-us is the default locale.

Once the translations are parsed, the generated type definitions are saved to /types/intl.d.ts. Since this file is auto-generated, there's no need to include it in version control—make sure to add it to your .gitignore.

const glob = require('glob');
const yaml = require('js-yaml');
const { readFileSync, writeFileSync } = require('fs');
const { parse: parseMessage, TYPE } = require('intl-messageformat-parser');

function flattenFormatElements(arr) {
  const children = arr
    .map((item) => flattenFormatElements(item.children ?? []))
    .flat();
  return [...arr, ...children];
}

function extractIcuArguments(translation) {
  const args = {};

  try {
    const ast = parseMessage(translation);
    const hasHtml = ast.some((item) => item.children);

    const walk = (nodes) => {
      for (const el of nodes) {
        switch (el.type) {
          case TYPE.literal:
            // plain text, ignore
            break;
          case TYPE.argument:
            // simple argument: {name}
            args[el.value] = 'string';
            break;
          case TYPE.select:
            // select argument: {gender, select, ...}
            args[el.value] = 'string';
            // walk options recursively
            Object.values(el.options).forEach((opt) => walk(opt.value));
            break;
          case TYPE.plural:
          case TYPE.number:
            // plural argument: {count, plural, ...}
            args[el.value] = 'number';
            // walk options recursively
            Object.values(el.options).forEach((opt) => walk(opt.value));
            break;
          default:
            // For completeness, handle nested messageFormatPattern arrays
            if (el.value && Array.isArray(el.value)) {
              walk(el.value);
            }
            break;
        }
      }
    };

    walk(flattenFormatElements(ast));

    if (hasHtml) args['htmlSafe?'] = 'boolean';
  } catch (err) {
    // fallback: assume no tokens
  }

  return args;
}

const allTranslations = {};

function flattenWithValues(obj, prefix = '') {
  for (const key in obj) {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    const val = obj[key];

    if (typeof val === 'string') {
      allTranslations[fullKey] = val;
    } else if (typeof val === 'object' && val !== null) {
      flattenWithValues(val, fullKey);
    }
  }
}

const translationFiles = glob.sync('translations/**/en-us.yml');

for (const file of translationFiles) {
  const content = readFileSync(file, 'utf8');
  const parsed = yaml.load(content);
  if (parsed) flattenWithValues(parsed);
}

const allKeys = new Set(Object.keys(allTranslations));

const lines = [
  `// Auto-generated from YAML translations`,
  `export interface IntlMessages {`,
  ...Array.from(allKeys)
    .sort()
    .map((key) => {
      const message = allTranslations[key]; // store flattened key → value map earlier
      const args =
        typeof message === 'string' ? extractIcuArguments(message) : {};
      const type =
        Object.keys(args).length === 0
          ? 'string'
          : `{ ${Object.entries(args)
              .map(([k, v]) => `${k}: ${v}`)
              .join('; ')} }`;
      return `  '${key}': ${type};`;
    }),
  `}`,
  ``,
];

writeFileSync('types/intl.d.ts', lines.join('\n'), 'utf8');

console.log('✅ types/intl.d.ts generated.');
/scripts/generate-intl-type-declarations.js

Update package.json

Next, we need to add a script to the package.json to run the type-generation script. Ideally, this script should run before key operations like build, start, or test. Your scripts section might look something like this:

{
	"scripts": {
		...,
		"intl:types": "node scripts/generate-intl-type-declarations.js",
		"start": "npm intl:types && ...",
		"build": "npm intl:types && ...",
		"test": "npm intl:types && ...",
	}
}

However, simply running intl:types before start isn’t always enough—especially in development. If your server is already running and you update a translation file, the type definitions won’t automatically update. To fix this, it’s important to watch translation files and regenerate type definitions whenever they change.

You have a few options for this:

  • Use tools like chokidar or concurrently to watch the translation files and rerun the script on change.
  • If your bundler supports customization, you can:

For example, here’s a plugin for Broccoli (used by Ember.js) that integrates this behavior.

const Plugin = require('broccoli-plugin');
const { execSync } = require('child_process');

module.exports = class GenerateIntlTypesPlugin extends Plugin {
  constructor(inputNodes, options = {}) {
    super(inputNodes, {
      name: 'GenerateIntlTypesPlugin',
      annotation: 'Generate intl.d.ts from translation YAML files',
      persistentOutput: true,
      needsCache: false,
    });

    this.options = options;
  }

  build() {
    try {
      execSync('npm run intl:types', { stdio: 'inherit' });
    } catch (err) {
      console.error('❌ Failed to generate intl.d.ts:', err);
    }
  }
};
/scripts/generate-intl-types-plugin.js

And in your ember-cli-build.js, you would configure it like this:

const mergeTrees = require('broccoli-merge-trees');
const GenerateIntlTypesPlugin = require('./scripts/generate-intl-types-plugin');

module.exports = function (defaults) {
  const app = new EmberApp(...); // Your confic and params
  
  const intlTypes = new GenerateIntlTypesPlugin([watchedTranslations]);

  return mergeTrees([app.toTree(), intlTypes], { overwrite: true });
};

Define the t Method Type

Now it's time to define a type for the t (translate) method.

import type { IntlMessages } from './intl'; // refers to 'types/intl.d.ts'

type TMethod = <K extends keyof IntlMessages>(
  key: K,
  ...args: IntlMessages[K] extends string ? [] : [IntlMessages[K]]
) => string
/types/global-intl.d.ts

Depending on your setup, you may:

  • Use this type to define a new translation function.
  • Override an existing t method and enhance it with type safety.

Either way, this ensures all translation usages are type-checked, helping you catch missing or incorrect keys at compile time rather than at runtime.

© 2025 Ramin Yavari. All rights reserved.